fix(plugin-chatbot): accept the AI SDK's UIMessage in the exported mappers - #8341
Merged
Merged
Conversation
…ppers `mapMessages.ts`'s local `AnyPart` is written to absorb whatever a producer hands the mapper — every other member is `string` / `unknown` — but `state` was typed against the OUTPUT contract (`ChatToolInvocation['state']`, the tool-invocation lifecycle). `@ai-sdk/react`'s text and reasoning parts carry `state: 'streaming' | 'done'`, which is not in that union, so the deliberately-permissive input interface was on that one property STRICTER than the union it exists to absorb and the whole `UIMessage[]` assignment was refused with TS2345 — at the exact call the docblock and the README export it for. Widen `AnyPart.state` to `string` and narrow at the single read site through an `isToolState` guard whose table is a `Record` over `ChatToolInvocation['state']`, so the output stays exactly as checked as before and the table cannot drift from the union it guards. `string` rather than the union of both known state sets: every other `AnyPart` member is already maximally loose, and enumerating one dependency's current literals in a published parameter type re-creates this same defect on the next SDK bump. The test's `msgs as never` is gone: the fixture is now typed `UIMessage[]` from `@ai-sdk/react`, so the assertion is about the dependency's real shape rather than a hand-rolled literal, and the compiler — not a cast — judges the seam. `useObjectChat.ts:683`'s `chatResult as any` is left in place deliberately; measured separately, it hides one unrelated diagnostic (see the PR body). The README's mapper block loses its `doc-snippet: fragment` marker and the prose warning above it: the block compiles as written now, and `check:doc-snippets` moves it from the declared-fragment tier into the compiled tier (631 -> 632 compiled, 159 -> 158 fragments, 0 failed). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
os-justin
marked this pull request as ready for review
September 7, 2026 13:43
os-justin
enabled auto-merge
September 7, 2026 13:43
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #8214
The defect
packages/plugin-chatbot/src/mapMessages.tsdeclares a deliberately permissive localAnyPart— every member isstring/unknown— but typed one member against the output contract:@ai-sdk/react's text and reasoning parts carrystate: 'streaming' | 'done', which is not in that union. So the interface written to absorb the SDK's part union was, on that one property, stricter than the union it exists to absorb, and the wholeUIMessage[]assignment failed — at the exact call the docblock and the README export the mappers for.The fix (PM ruling: option A)
AnyPart.stateis nowstring, and the file's single read site narrows through a guard:Why
stringand not the union of both known state sets. Both widens cost the same at the read site — neither one letsp.stateflow intoChatToolInvocation['state']without an explicit narrowing, becauseAnyPartis a flat interface and thep.typefilter above does not narrow a sibling property. Given equal cost,stringis the one consistent with the ruling: it is a purely local widening. Enumerating one dependency's current literals inside a published parameter type keeps the type coupled to@ai-sdk/react@4.0.68without the SDK's type as the source of truth — the coupling of option B with none of its authority — and re-creates this identical defect on the next SDK bump.stringmatches every sibling member ofAnyPartand ends the class.Cost at the read sites.
mapMessages.tshas exactly one read ofp.state(line 634 on the base; the other fourstateoccurrences inextractToolInvocationsare locals). It pays one guard call. TheRecordover the union is what keeps the guard honest in both directions: a typo in the table is a compile error, and a state added toChatToolInvocation['state']fails here until a row is added.Behaviour fix that falls out of it. An unrecognized state string (an AI SDK v4 snapshot's
'result', say) used to pass through verbatim intoChatToolInvocation.state, then fall past every branch ingetToolStateand render a finished call as "Running" forever. It now normalizes toundefined, which is that function's documented "infer fromerrorText/result" case. The output type is now true at runtime instead of merely asserted.Evidence
Instrument is the package's own
type-check(tsc --noEmit && tsc -p tsconfig.test.json), not vitest — vitest transpiles types away.tsconfig.test.jsonwas confirmed to actually read the test file (--listFiles, 1876 files,src/__tests__/mapMessages.test.tspresent; a made-up filename control returns 0 hits).type-checkexit 0TS2345atmapMessages.test.ts(136,42), the card's exact chain down toType '"done"' is not assignable to …type-checkexit 0staterestored on the committed implementationTS2345by name--filter '...@object-ui/plugin-chatbot')Scope: 7 of 47 workspace projects, all 7Done, 0 errorspnpm --filter @object-ui/plugin-chatbot testeslint .(package-scoped)Ablation discipline: mutation proved on disk by
git hash-objectdiffering fromgit rev-parse HEAD:PATH, restore proved by state (git diff HEADempty and the hashes equal again),trap … EXIT INT TERMwith absolute paths, from a committed implementation. A separate positive control confirms the vitest case is really executed: an injected failing expectation turns that exact file and test name red, then is restored by state.The two load-bearing casts
mapMessages.test.ts:135msgs as never— removed. The fixture is now declaredUIMessage[]imported from@ai-sdk/react, so the pin is about the dependency's real shape rather than a hand-rolled literal. Note the cast was doing more than the card's diagnostic: the untyped literal also hadrole: string, so the cast was hiding two things. The SDK type fixes both, and nothing else in the fixture had to change.useObjectChat.ts:683chatResult as any— measured, left in place. With the widen applied, removing it leaves exactly one diagnostic, and it has nothing to do with this card:TS2322atuseObjectChat.ts:803— the hook's own returnedsetMessagesis declared(messages: unknown[]) => voidwhile the SDK's is(messages: UIMessage[] | updater) => void, which is contravariantly incompatible. Out of scope here; reported rather than chased.That measurement also substantiates the card's central claim rather than repeating it. With the narrow
staterestored and theas anyremoved, the mapper call in production source goes red too:TS2345atuseObjectChat.ts(701,69). So the widen fixes the package's own call site, not just the test — theas anyhad been masking this defect as well as the unrelated one.README
The
doc-snippet: fragmentmarker does exist on this base (packages/plugin-chatbot/README.md, line 361), so batch 30's PR #8228 has landed. It is removed here, and the doc-snippet gate is the instrument that proves the block now compiles:Covered blocks: 790 — 631 to compile, 159 declared fragment(s)./Semantic phase: 631 of 631 block(s) judged, 0 failed.— exit 0Covered blocks: 790 — 632 to compile, 158 declared fragment(s)./Semantic phase: 632 of 632 block(s) judged, 0 failed.— exit 0distrebuilt from the ablated source[semantic] packages/plugin-chatbot/README.md:371:42 TS2345 …Exit 1 is "I ran and found errors", not the exit 2 "PRECONDITION NOT MET" a fresh worktree gives before its closure is built; that closure (34 filters, from the gate's own
--build-filter) was built for every one of these runs.One addition beyond the brief: the seven-line prose blockquote directly above the marker — "Today this call needs a cast on the reader's side…" — is removed with it. It documents the defect as a live constraint on the reader; leaving it would keep the page telling readers to write a cast the compiler no longer wants, which is the same over-claim in prose that the marker was in metadata.
Changeset
minoron@object-ui/plugin-chatbot. Not breaking: the parameter type only got looser, so every argument that compiled before still compiles (verified —dist/mapMessages.d.tsnow emitsstate?: string;).minorrather thanpatchbecause a published signature accepts input it previously refused, which is a capability a consumer can newly depend on. The repo forbidsmajor;pnpmgatecheck-changeset-no-majoris green, as arecheck-changeset-presence/-fixed/-overwrite.Other gates run
check:control-bytes,check:readme-exports,check:doc-fences,check:doc-types,check:phantom-deps,check:unused-deps— all exit 0.check-governed-queue-guard --teston the four changed paths:NOT GOVERNED — 4 path(s) checked against 5 governed surface(s); none matched.🤖 Generated with Claude Code
https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Generated by Claude Code